﻿//*************************************************************************************************
// HtmlTagInnerTextParser.cs
// Owner: Herodotos Herodotou (t-herohe)
//
// A specialized parser the is used to extract the inner text from html tags
// located in some html document.
//
// Copyright(c) Microsoft Corporation, 2008
//*************************************************************************************************

using System;
using System.Collections.Generic;

using Microsoft.VisualStudio.TestTools.WebTesting;
using System.Text;

namespace WebAndLoadTestPluginsAndRules
{
    /// <summary>
    /// An html parser specialized in parsing out the inner text of html tags.
    /// </summary>

    public class HtmlTagInnerTextParser
    {

        // ****************************************************************************
        // Public Methods
        // ****************************************************************************
        public HtmlTagInnerTextParser(string documentContent)
        {
            m_documentContent = documentContent;
            m_currentAttributeList = new List<HtmlAttribute>();
            m_currentAttributeName = new StringBuilder();
            m_currentAttributeValue = new StringBuilder();
            m_currentTag = new StringBuilder();
        }

        /// <summary>
        /// Gets an enumerable list of all the inner text that match the specified parameters
        /// </summary>
        /// <param name="tagName">The name of the tag to look for</param>
        /// <param name="hasClosingTag">Specify if this tag has a closing tag</param>
        /// <param name="removeInnerTags">Specify if the inner text should be filtered</param>
        /// <param name="collapseWhiteSpace">Specify if the extra white space should be collapsed</param>
        /// <returns>Enumerable list of the inner text of the matching tags</returns>
        public IEnumerable<InnerText> GetInnerTextForHtmlTags(string tagName, bool hasClosingTag,
                                                              bool removeInnerTags, bool collapseWhiteSpace)
        {
            return GetInnerTextForHtmlTags(tagName, null, null, hasClosingTag, removeInnerTags,
                collapseWhiteSpace, 0, m_documentContent.Length - 1);
        }

        /// <summary>
        /// Gets an enumerable list of all the inner text that match the specified parameters
        /// </summary>
        /// <param name="tagName">The name of the tag to look for</param>
        /// <param name="hasClosingTag">Specify if this tag has a closing tag</param>
        /// <param name="removeInnerTags">Specify if the inner text should be filtered</param>
        /// <param name="collapseWhiteSpace">Specify if the extra white space should be collapsed</param>
        /// <param name="beginParseOffset">The beginning offset for the parsing</param>
        /// <param name="endParseOffset">The ending offset for the parsing</param>
        /// <returns>Enumerable list of the inner text of the matching tags</returns>
        public IEnumerable<InnerText> GetInnerTextForHtmlTags(string tagName, bool hasClosingTag, bool removeInnerTags,
                                                              bool collapseWhiteSpace, int beginParseOffset, int endParseOffset)
        {
            return GetInnerTextForHtmlTags(tagName, null, null, hasClosingTag, removeInnerTags,
                collapseWhiteSpace, beginParseOffset, endParseOffset);
        }

        /// <summary>
        /// Gets an enumerable list of all the inner text that match the specified parameters
        /// </summary>
        /// <param name="tagName">The name of the tag to look for</param>
        /// <param name="attributeName">The name of the attribute to match</param>
        /// <param name="attributeValue">The value of the attribute to match</param>
        /// <param name="hasClosingTag">Specify if this tag has a closing tag</param>
        /// <param name="removeInnerTags">Specify if the inner text should be filtered</param>
        /// <param name="collapseWhiteSpace">Specify if the extra white space should be collapsed</param>
        /// <returns>Enumerable list of the inner text of the matching tags</returns>
        public IEnumerable<InnerText> GetInnerTextForHtmlTags(string tagName,
                                                              string attributeName,
                                                              string attributeValue,
                                                              bool hasClosingTag,
                                                              bool removeInnerTags,
                                                              bool collapseWhiteSpace)
        {
            return GetInnerTextForHtmlTags(tagName, attributeName, attributeValue, hasClosingTag,
                removeInnerTags, collapseWhiteSpace, 0, m_documentContent.Length - 1);
        }


        /// <summary>
        /// Gets an enumerable list of all the inner text that match the specified parameters
        /// </summary>
        /// <param name="tagName">The name of the tag to look for</param>
        /// <param name="attributeName">The name of the attribute to match</param>
        /// <param name="attributeValue">The value of the attribute to match</param>
        /// <param name="hasClosingTag">Specify if this tag has a closing tag</param>
        /// <param name="removeInnerTags">Specify if the inner text should be filtered</param>
        /// <param name="collapseWhiteSpace">Specify if the extra white space should be collapsed</param>
        /// <param name="beginParseOffset">The beginning offset for the parsing</param>
        /// <param name="endParseOffset">The ending offset for the parsing</param>
        /// <returns>Enumerable list of the inner text of the matching tags</returns>
        public IEnumerable<InnerText> GetInnerTextForHtmlTags(string tagName,
                                                              string attributeName,
                                                              string attributeValue,
                                                              bool hasClosingTag,
                                                              bool removeInnerTags,
                                                              bool collapseWhiteSpace,
                                                              int beginParseOffset,
                                                              int endParseOffset)
        {
            // Notes: We need to keep track of the old current index in order to restore it when all the
            // matching items are excausted. We need to keep truck of the local current offset in the
            // case the user nests calls to this method.
            int oldCurrentIndex = m_currentOffset;
            int localCurrentOffset = beginParseOffset;
            m_currentOffset = beginParseOffset;

            while (!IsEndParseRegion())
            {
                ResetOffsets(beginParseOffset, endParseOffset, localCurrentOffset);

                HtmlTagWithOffsets openTag = GetSpecificOpeningTag(tagName, attributeName, attributeValue);
                if (openTag != null && m_currentSelfClosingTag == false)
                {
                    // Found an opening tag
                    HtmlTagWithOffsets closingTag = GetSpecificClosingTag(tagName, hasClosingTag);
                    if (closingTag != null)
                    {
                        // Found the corresponding closing tag
                        localCurrentOffset = m_currentOffset;
                        m_currentOffset = oldCurrentIndex;
                        yield return new InnerText(m_documentContent, openTag.EndOffset + 1,
                            closingTag.BeginOffset - 1, removeInnerTags, collapseWhiteSpace);
                    }
                    else
                    {
                        m_currentOffset = oldCurrentIndex;
                        yield break;
                    }
                }
                else if (openTag != null && m_currentSelfClosingTag == true)
                {
                    // Found a self closing tag
                    localCurrentOffset = m_currentOffset;
                    m_currentOffset = oldCurrentIndex;
                    yield return new InnerText();
                }
                else
                {
                    // Restore the old values of the offsets
                    m_currentOffset = oldCurrentIndex;
                    yield break;
                }
            }

            m_currentOffset = oldCurrentIndex;
        }


        // ****************************************************************************
        // Private Methods
        // ****************************************************************************

        /// <summary>
        /// Add a parsed attribute to the current collection.
        /// </summary>
        private void AddAttribute()
        {
            HtmlAttribute a = new HtmlAttribute(m_currentAttributeName.ToString(), m_currentAttributeValue.ToString());
            m_currentAttributeList.Add(a);
        }

        /// <summary>
        /// Initializes all the class variables
        /// </summary>
        private void InitializeParser()
        {
            m_currentOffset = 0;
            m_currentAttributeName.Remove(0, m_currentAttributeName.Length);
            m_currentAttributeValue.Remove(0, m_currentAttributeValue.Length);
            m_currentTag.Remove(0, m_currentTag.Length);
            m_currentTagBeginOffset = 0;
            m_currentTagEndOffset = 0;
            m_currentSelfClosingTag = false;
            m_currentAttributeList.Clear();
        }

        /// <summary>
        /// Determine if the end of the document text has been reached.
        /// </summary>
        /// <returns>True if the end of the document text has been reached.</returns>
        private bool IsEndParseRegion()
        {
            return (m_currentOffset > m_endParseOffset || m_currentOffset >= m_documentContent.Length);
        }


        /// <summary>
        /// Get the current character that is being parsed.
        /// </summary>
        /// <returns>The character at the current position.</returns>
        private char GetCurrentChar()
        {
            return m_documentContent[m_currentOffset];
        }

        /// <summary>
        /// Get the current character and advance the index by one.
        /// </summary>
        /// <returns>The current character</returns>
        private char GetCurrentCharAndAdvance()
        {
            return m_documentContent[m_currentOffset++];
        }

        /// <summary>
        /// Get a few characters ahead of the current character.
        /// </summary>
        /// <param name="peek">How many characters to peek ahead for.</param>
        /// <returns>The character that was retrieved.</returns>
        private char GetPeekChar(int peek)
        {
            if ((m_currentOffset + peek) < m_endParseOffset ||
                (m_currentOffset + peek) < m_documentContent.Length)
            {
                return m_documentContent[m_currentOffset + peek];
            }
            else
            {
                return (char)0;
            }
        }

        /// <summary>
        /// Get a special tag object that signals the end of the parsing region
        /// </summary>
        /// <returns>A special tag object that signals the end of the parsing region</returns>
        private HtmlTagWithOffsets GetEndParsingRegionTag()
        {
            // Create the tag object
            HtmlTagWithOffsets tag = new HtmlTagWithOffsets();
            tag.BeginOffset = m_endParseOffset + 1;
            tag.EndOffset = m_endParseOffset + 1;

            return tag;
        }

        /// <summary>
        /// Get the next tag object
        /// </summary>
        /// <returns>The next tag object</returns>
        private HtmlTagWithOffsets GetNextTag()
        {
            HtmlTagWithOffsets tag = null;

            if (ParseUntilNextTag())
            {
                ParseTag();

                // Create the tag object
                tag = new HtmlTagWithOffsets();
                tag.Name = m_currentTag.ToString();
                tag.BeginOffset = m_currentTagBeginOffset;
                tag.EndOffset = m_currentTagEndOffset;

                foreach (HtmlAttribute attribute in m_currentAttributeList)
                {
                    tag.AddAttribute(attribute);
                }
            }

            return tag;
        }


        /// <summary>
        /// Get the next tag that matches the tag name specified in the parameter
        /// </summary>
        /// <param name="tagName">The name of the tag to look for</param>
        /// <param name="hasClosingTag">Specify if this tag has a closing tag</param>
        /// <returns>The tag object with the tag name specified in the parameter</returns>
        private HtmlTagWithOffsets GetSpecificClosingTag(string tagName, bool hasClosingTag)
        {
            int openTagsCounter = 1;
            bool foundNestedTags = false;
            int beginIndex = m_currentOffset;

            while (!IsEndParseRegion())
            {
                HtmlTagWithOffsets currentTag = GetNextTag();
                if (currentTag != null)
                {
                    // Look for an opening tag of the same type
                    if (string.Equals(currentTag.Name, tagName, StringComparison.OrdinalIgnoreCase))
                    {
                        // Keep track of nesting if we are looking for a closing tag
                        if (hasClosingTag == true)
                        {
                            foundNestedTags = true;
                            ++openTagsCounter;
                        }
                        else
                        {
                            // Revert to the beginning of this opening tag in order to get parsed later on
                            m_currentOffset = currentTag.BeginOffset;
                            return currentTag;
                        }
                    }

                    // Look for a closing tag
                    if (currentTag.Name.IndexOf('/') == 0 &&
                        string.Equals(currentTag.Name.Substring(1), tagName, StringComparison.OrdinalIgnoreCase))
                    {
                        if (hasClosingTag == true)
                        {
                            --openTagsCounter;
                        }

                        if (hasClosingTag == false || openTagsCounter == 0)
                        {
                            if (foundNestedTags == true)
                            {
                                //Revert to the beginning of the search in order to parse the nested tags later on
                                m_currentOffset = beginIndex;
                            }

                            return currentTag;
                        }
                    }
                }
            }

            return GetEndParsingRegionTag();
        }


        /// <summary>
        /// Get the next opening tag that matches the tag name specified in the parameter
        /// </summary>
        /// <param name="tagName">The name of the tag to look for</param>
        /// <returns>The tag object with the tag name specified in the parameter</returns>
        private HtmlTagWithOffsets GetSpecificOpeningTag(string tagName)
        {
            return GetSpecificOpeningTag(tagName, null, null);
        }


        /// <summary>
        /// Get the next opening tag that matches the specified parameters
        /// </summary>
        /// <param name="tagName">The name of the tag to look for</param>
        /// <returns>The tag object that matches the specified parameters</returns>
        private HtmlTagWithOffsets GetSpecificOpeningTag(string tagName, string attributeName, string attributeValue)
        {
            while (!IsEndParseRegion())
            {
                HtmlTagWithOffsets currentTag = GetNextTag();
                if (currentTag != null &&
                    string.Equals(currentTag.Name, tagName, StringComparison.OrdinalIgnoreCase))
                {
                    if (attributeName == null && attributeValue == null)
                    {
                        // Only looking for a tag that matches the name
                        return currentTag;
                    }

                    // Looking for a tag that matches all parameters
                    string currentAttrValue = currentTag.GetAttributeValueAsString(attributeName);
                    if (currentAttrValue != null &&
                        string.Equals(currentAttrValue, attributeValue, StringComparison.OrdinalIgnoreCase))
                    {
                        // Found a matching tag
                        return currentTag;
                    }
                }
            }

            return null;
        }


        /// <summary>
        /// Advance the index until past any whitespace.
        /// </summary>
        private void IgnoreWhiteSpace()
        {
            while (!IsEndParseRegion() && IsWhiteSpace(GetCurrentChar()))
            {
                ++m_currentOffset;
            }
        }

        /// <summary>
        /// Determine if the specified character is part of the end of the tag.
        /// i.e. '>' or "/>"
        /// </summary>
        /// <param name="ch">A character to check</param>
        /// <returns>True if the character is part of the end of the tag</returns>
        private bool IsPartOfClosingTag(char ch)
        {
            return (ch == '>') || (ch == '/' && GetPeekChar(1) == '>');
        }

        /// <summary>
        /// Determine if the specified character is part of a self closing tag.
        /// </summary>
        /// <param name="ch">A character to check</param>
        /// <returns>True if the character is part of a self closing tag</returns>
        private bool IsPartOfSelfClosingTag(char ch)
        {
            return (ch == '/' && GetPeekChar(1) == '>');
        }

        /// <summary>
        /// Determine if the specified character is whitespace or not.
        /// </summary>
        /// <param name="ch">A character to check</param>
        /// <returns>True if the character is whitespace</returns>
        private bool IsWhiteSpace(char ch)
        {
            return ("\t\n\r ".IndexOf(ch) != -1);
        }


        /// <summary>
        /// Parse the attribute name.
        /// </summary>
        private void ParseAttributeName()
        {
            IgnoreWhiteSpace();

            // Get attribute name
            char ch;
            while (!IsEndParseRegion())
            {
                ch = GetCurrentChar();
                if (IsWhiteSpace(ch) || (ch == '=') || IsPartOfClosingTag(ch))
                {
                    break;
                }
                m_currentAttributeName.Append(ch);
                ++m_currentOffset;
            }

            IgnoreWhiteSpace();
        }


        /// <summary>
        /// Parse the attribute value
        /// </summary>
        private void ParseAttributeValue()
        {
            if (GetCurrentChar() == '=')
            {
                ++m_currentOffset;
                IgnoreWhiteSpace();

                if ((GetCurrentChar() == '\'') || (GetCurrentChar() == '\"'))
                {
                    // Value is surrounded by " or '
                    char parseDelim = GetCurrentChar();
                    ++m_currentOffset;
                    while (!IsEndParseRegion() && GetCurrentChar() != parseDelim)
                    {
                        m_currentAttributeValue.Append(GetCurrentChar());
                        ++m_currentOffset;
                    }
                    ++m_currentOffset;
                }
                else
                {
                    // Value is not surrounded by " or '
                    char ch;
                    while (!IsEndParseRegion())
                    {
                        ch = GetCurrentChar();
                        if (IsWhiteSpace(ch) || IsPartOfClosingTag(ch))
                        {
                            break;
                        }
                        m_currentAttributeValue.Append(ch);
                        ++m_currentOffset;
                    }
                }

                IgnoreWhiteSpace();
            }
        }


        /// <summary>
        /// Parse a comment
        /// </summary>
        private void ParseComment()
        {
            while (!IsEndParseRegion())
            {
                if ((GetCurrentChar() == '-') && (GetPeekChar(1) == '-') && (GetPeekChar(2) == '>'))
                {
                    break;
                }
                ++m_currentOffset;
            }

            m_currentOffset += 3;
        }

        /// <summary>
        /// Parse an html tag
        /// </summary>
        private void ParseTag()
        {
            m_currentTagBeginOffset = m_currentOffset - 1;
            m_currentTag.Remove(0, m_currentTag.Length);
            m_currentAttributeList.Clear();
            IgnoreWhiteSpace();

            // Get the tag name
            char ch;
            while (!IsEndParseRegion())
            {
                ch = GetCurrentChar();
                if (IsWhiteSpace(ch) || IsPartOfClosingTag(ch))
                {
                    break;
                }
                m_currentTag.Append(ch);
                ++m_currentOffset;
            }

            IgnoreWhiteSpace();

            // Get the attributes (name/value pairs)
            while (!IsPartOfClosingTag(GetCurrentChar()))
            {
                m_currentAttributeName.Remove(0, m_currentAttributeName.Length);
                m_currentAttributeValue.Remove(0, m_currentAttributeValue.Length);

                ParseAttributeName();
                ParseAttributeValue();

                AddAttribute();
            }

            if (IsPartOfSelfClosingTag(GetCurrentChar()))
            {
                m_currentSelfClosingTag = true;
                m_currentOffset += 2;
            }
            else
            {
                m_currentSelfClosingTag = false;
                ++m_currentOffset;
            }

            m_currentTagEndOffset = m_currentOffset - 1;
        }


        /// <summary>
        /// Parse the document until the beginning of the next tag
        /// </summary>
        private bool ParseUntilNextTag()
        {
            while (!IsEndParseRegion())
            {
                if (GetCurrentCharAndAdvance() == '<')
                {
                    // Check for a comment
                    if ((GetCurrentChar() == '!') && (GetPeekChar(1) == '-') && (GetPeekChar(2) == '-'))
                    {
                        ParseComment();
                    }
                    else
                    {
                        char ch = char.ToUpper(GetCurrentChar());
                        if ((ch >= 'A') && (ch <= 'Z') || (ch == '!') || (ch == '/'))
                        {
                            // Found a tag
                            return true;
                        }
                    }
                }
            }

            return false;
        }


        private void ResetOffsets(int beginParseOffset, int endParseOffset, int currentOffset)
        {
            m_beginParseOffset = beginParseOffset;
            m_endParseOffset = endParseOffset;
            m_currentOffset = currentOffset;
        }


        // ************************************************************************
        // Private Data Members.
        // ************************************************************************

        // The source text that is being parsed and the parsing region.
        private string m_documentContent;
        private int m_beginParseOffset;
        private int m_endParseOffset;

        // The current position inside of the text that is being parsed.
        private int m_currentOffset;

        // The most recently parsed objects
        private StringBuilder m_currentAttributeName;
        private StringBuilder m_currentAttributeValue;
        private StringBuilder m_currentTag;
        private int m_currentTagBeginOffset;
        private int m_currentTagEndOffset;
        private bool m_currentSelfClosingTag;
        private List<HtmlAttribute> m_currentAttributeList;
    }
}
